-
Notifications
You must be signed in to change notification settings - Fork 134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add APQ support #555
Add APQ support #555
Conversation
else if (response.Errors.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported") || | ||
response.Errors.Any(error => string.Equals(error.Message, "GraphQL query is missing.")))) // GraphQL.NET specific error message |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if it's another GraphQL server which has a slightly different response? This repo is not exclusively for GraphQL servers that either (1) support APQ or (2) are GraphQL.NET. I'm not sure, but even GraphQL.NET might return different messages for older versions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know. It's more like demo to discuss. My implementation has more conditions to consider - http status codes, graphql error codes in extensions, etc. - but I minimized them to minimum to make code clean. I'm open to suggestions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apollo uses a delegate to determine if it should disable persistent queries for the session...
disable
: a function which takes anErrorResponse
(see below) and returns a boolean to disable any future persisted queries for that session. This defaults to disabling onPersistedQueryNotSupported
or a 400 or 500 http error.
We'd have to handle the GraphQLHttpRequestException
from SendHttpRequestAsync
there, too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PersistedQueryNotSupported
error is propably the most unlikely... because then the server needs to know what a persisted query looks like which means its most likely supported but explicitly turned off..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the error handling is a bit of a pain, there doesn't seem to be any error code or structured response indicating missing or incorrect hash, I'm afraid we must just link currently known responses, for particular implementations to behaviors we want. Maybe at one point they will come up with a generic way to report such errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'd have to handle the GraphQLHttpRequestException from SendHttpRequestAsync there, too
Still in TODO.
I use my own GraphQL client implementation, and it is based on generics; I wouldn't have a dictionary myself. Although the serializer would handle a dictionary if it was passed it. The same may be true here (?) |
Problem with extensions is in design. What if someone set request.Extensions = new Person() before passing request into client? How to deal with arbitrary initialized object? How to add new data into it? Ping @rose-a , please comment. |
@Shane32 how would you handle a generic extensions type when a user want's to use his own extension in combination with the integrated APQ feature? I think making It also fits the GraphQLOverHTTP Spec Draft where its specified as a map... |
Then I suggest to open another PR changing extensions property to Also note that the problem is not only between built-in functionality (like APQ or something else) and user extensions. User may use graphql client in such a way that different modules may want to set their own extensions and conflict on shared |
I don't use this library, but for my library, I'd serialize the user's anonymous object to a JsonElement and append the APQ data. Let's ask ChatGPT for a sample: ChatGPT 4 requestWrite a C# function with this signature: Dictionary<string, object> AddAPQSupport(object extensions, int version, string sha256Hash) The function should serialize 'extensions' to a JsonElement, expecting a map, and add two keys to the map, "version" and "sha256Hash". If extensions is null it should create a new map ChatGPT 4 responseusing System;
using System.Collections.Generic;
using System.Text.Json;
public class APQSupport
{
public static Dictionary<string, object> AddAPQSupport(object extensions, int version, string sha256Hash)
{
Dictionary<string, object> resultMap;
if (extensions == null)
{
resultMap = new Dictionary<string, object>();
}
else
{
try
{
string jsonString = JsonSerializer.Serialize(extensions);
resultMap = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonString);
}
catch (Exception)
{
throw new ArgumentException("Invalid 'extensions' input. It must be a serializable map.");
}
}
resultMap["version"] = version;
resultMap["sha256Hash"] = sha256Hash;
return resultMap;
}
}
|
Keep in mind that a dictionary works just as well to be fed into this as an anonymous type. Now in my own library, I also support pre-encoded JSON strings as inputs to extensions/variables, not just anonymous types. Just because we are having fun here, let's have ChatGPT add that feature, and also optimize it a bit: Request 2change it so that if a string is passed in to extensions, it is assumed to be json and deserialized Request 3utilize the SerializeToElement method so it does not need to be serialized to a string Final ChatGPT 4 outputusing System;
using System.Collections.Generic;
using System.Text.Json;
public class APQSupport
{
public static Dictionary<string, object> AddAPQSupport(object extensions, int version, string sha256Hash)
{
Dictionary<string, object> resultMap;
if (extensions == null)
{
resultMap = new Dictionary<string, object>();
}
else
{
try
{
JsonElement jsonElement;
if (extensions is string jsonString)
{
jsonElement = JsonDocument.Parse(jsonString).RootElement;
}
else
{
jsonElement = JsonSerializer.SerializeToElement(extensions);
}
resultMap = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonElement.GetRawText());
}
catch (Exception)
{
throw new ArgumentException("Invalid 'extensions' input. It must be a serializable map or a valid JSON string.");
}
}
resultMap["version"] = version;
resultMap["sha256Hash"] = sha256Hash;
return resultMap;
}
} |
It still doesn't deserialize directly from the JsonElement. Just deleting |
@rose-a If I remember right, this library supports multiple serializers, so while this answer works for me, may not be the best answer here. It's just an idea. |
mh... while this solution would propably work its really hacky in my opinion and it results in a So if someone puts something into the extensions object which is not serializable to a map it explodes at runtime instead of during or before compilation. (It propably would explode on the server side, anyway, at least with GraphQL.NET, as that uses an |
Yeah, no problem. Just FYI, deserializing to |
other option is to make it a base type (with only persisted query as property), that one may inherit from, and add additional properties. It's not as flexible as anonymous objecta as many currently probably use but a bit cleaner than making it a dictionary. In any case that seems to be a breaking change in the API. That would leave option to control the serialziation process and options and use whatever custom serialization (although we'd need to handle serialziation of those base properties for at least two default serializers) |
@@ -19,6 +19,13 @@ public GraphQLHttpResponse(GraphQLResponse<T> response, HttpResponseHeaders resp | |||
public HttpStatusCode StatusCode { get; set; } | |||
} | |||
|
|||
public interface IGraphQLHttpResponse : IGraphQLResponse |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This interface is needed to pass non-generic instance of response into GraphQLHttpClientOptions.DisableAPQ
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should put the content in there, too... then people can evaluate plaintext error messages...
Always having the raw content in there might be beneficial for general debugging, too, cause then people could cast their responses to IGraphQLHttpResponse
and get the raw body of the response...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then I would put entire HttpResponseMessage
instead of pulling discrete properties from it. Also note that content may be already unaccessable since stream was read in SendHttpRequestAsync
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#559 , not a goal for this PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sungam3r I picked up your work and made the following amendments:
- Refactor GraphQLHttpClient to allow usage of APQ via websocket connection, too
- Move hash/extension generation into GraphQL.Primitives and create a
GraphQLQuery
class which allows to declare a reusable query string with the hash computed only once during object creation. - Add basic tests
@Shane32 @mikocot @sungam3r please review, I'd especially like your opinion on the current handling of the hash value in the GraphQLRequest class
The current implementation does not pre-compute the hash if it is created using the constructor accepting a string parameter, and it clears a hash taken from a GraphQLQuery
constructor argument when setting the Query
property.
This way there is no additional overhead to compute hashes in case APQ is not used, but if someone reuses the GraphQLRequest instance it would always be re-computed before sending. Alternatively we could compute the hash in the setter of the Query property, but that would then also happen if APQ is not used at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's a few comments
byte[]? inputBytes = ArrayPool<byte>.Shared.Rent(expected); | ||
int written = Encoding.UTF8.GetBytes(query, 0, query.Length, inputBytes, 0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be rewritten to use Span / stackalloc also.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please elaborate? 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I saw that stackalloc was used on lines 21-27. Stackalloc could be used for the call to GetBytes as well. This is faster than ArrayPool.
https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc
The easy recommended pattern is to write code like this:
int length = 1000;
Span<byte> buffer = length <= 1024 ? stackalloc byte[length] : new byte[length];
This assumes that typically the buffer length is relatively small, and in such cases, is ideal. It also includes a fallback for large buffer requirements; but then you'd probably lose the ArrayPool (?). And if it was known that the query would commonly be larger than you'd want to allocate on the stack, then maybe the cost of the heap allocation for the long queries might be worse than the benefits from stackalloc. Or you'd have to write a separate if block for the ArrayPool path vs not. I'm not really sure; maybe better off leaving it as-is.
Another possible optimization would be to guess at the length, rather than using GetByteCount. You'd have to write a fallback routine in case the encoded string length was longer than the preallocated buffer though, and you might need to use the underlying Encoder rather than the static method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'll leave it as it is. Using Span<byte>
would require separate code for old frameworks again, and the best optimization is using the GraphQLQuery
class to compute the hash only once... but anyway, thanks for the explanation!
Co-authored-by: Shane Krueger <shane@acdmail.com>
Co-authored-by: Shane Krueger <shane@acdmail.com>
fixes #548
@rose-a @Shane32 please review, especially TODO note about extensions type
Also I would like to note that design of GraphQL client from this repo allows to send arbitrary queries to the server since caller may build arbitrary strings, for example, ones with concatenated literals with different values
Server cache for APQ may be polluted. In my in-house GraphQL client I do not allow to pass arbitrary strings. Instead this client autogenerates query based on used arguments in autogenerated methods and places all values into variables so the query text itself is already constant value like
Nevertheless, I'm fine to add general possibility to use APQ here.